package com.cellngine.server;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.HashMap;

import javax.crypto.Cipher;

import com.cellngine.Constants;
import com.cellngine.JO;
import com.cellngine.cellngine;

public class ServerClientSocket extends Thread
{
	private final Socket		socket;
	public final ServerManager	master;
	public final String			ip;
	private ServerClient		client;
	private byte[]				key;
	private InputStream			inputStream;
	private OutputStream		outputStream;
	private SecureRandom		clientPRNG;	// Used to decrypt messages from the client
	private SecureRandom		serverPRNG;	// Used to encrypt messages to the client
												
	public ServerClientSocket(final ServerManager master, final Socket socket)
	{
		ip = ((InetSocketAddress) socket.getLocalSocketAddress()).getAddress().getHostAddress();
		this.socket = socket;
		this.master = master;
	}
	
	@Override
	public void run()
	{
		try
		{
			inputStream = socket.getInputStream();
			outputStream = socket.getOutputStream();
			int inputChar;
			inputChar = inputStream.read();
			/*"real" clients identify themselves by sending a 0x01 byte.
			 * If a client wants information about the server in an XML format, it should send a
			 * 0x00 byte.
			 * If anything else is sent to the server, it will print out a human-readable copy
			 * of basic information about the server (whether or not a password is required
			 * etc.)
			 */
			if (inputChar == 1)
			{
				establishSecure();
				return;
			}
			else if (inputChar == 0)
			{
				sendInfoXML();
				return;
			}
			else
			{
				sendInfoPlainText();
				return;
			}
		}
		catch (final IOException e)
		{
			cellngine.log(e, Constants.log_type.error);
		}
		close();
	}
	
	private void sendInfoXML()
	{
		final HashMap<String, String> serverInfos = new HashMap<String, String>();
		serverInfos.put("PasswordEnabled", master.passwordEnabled() ? "true" : "false");
		serverInfos.put("MaxPlayers", master.getMaxPlayers() + "");
		serverInfos.put("ConnectionsPerClient", Constants.connections_per_client + "");
		serverInfos.put("SocketTimeout", Constants.socket_timeout_ms + "");
		serverInfos.put("UsesHub", (master.isHubRegistered() ? "true" : "false") + "");
		serverInfos.put("HubURL", master.hubURL());
		send(JO.hashmapToXML(serverInfos));
		close();
	}
	
	private void sendInfoPlainText()
	{
		send("--- Server details ---\r\n");
		send("cellngine version: " + Constants.VERSION_MAJOR + "." + Constants.VERSION_MINOR + " R"
				+ Constants.VERSION_REVISION + "\r\n");
		send("Password required: " + (master.passwordEnabled() ? "yes" : "no") + "\r\n");
		send("Maximum player number: " + master.getMaxPlayers() + "\r\n");
		send("Hub secured: " + (master.isHubRegistered() ? "yes" : "no") + "\r\n");
		close();
	}
	
	private void establishSecure()
	{
		/**
		 * We first respond with a 0x01 byte to indicate that the connection is working
		 */
		send(new byte[] { 0x01 });
		/**
		 * We then wait for the client to send us the connection-specific encryption key, which is
		 * encrypted with our public key.
		 */
		final byte[] keyEncrypted = receiveBytes(Constants.elgamal_key_bytes);
		if (keyEncrypted == null)
		{
			return;
		}
		byte[] keyDecrypted;
		try
		{
			keyDecrypted = master.hubCrypt(keyEncrypted, Cipher.DECRYPT_MODE);
		}
		catch (final Exception e)
		{
			error("Unable to decrypt temporary connection key.", e);
			close();
			return;
		}
		if (keyDecrypted == null)
		{
			return;
		}
		/**
		 * After we decrypted the connection-specific key, we receive it's checksum and verify it's
		 * integrity.
		 */
		final byte[] keyChecksum = receiveBytes(Constants.sha2_length_bytes);
		if (keyChecksum == null)
		{
			return;
		}
		final byte[] keyChecksumOwn = JO.SHA2(keyDecrypted, Constants.sha2_length_bytes * 8);
		if (!keyChecksumOwn.equals(keyChecksum))
		{
			error("Session key exchange checksum error.", null);
			close();
			return;
		}
		key = keyDecrypted;
		try
		{
			clientPRNG = SecureRandom.getInstance("SHA1PRNG");
			clientPRNG.setSeed(key);
			serverPRNG = SecureRandom.getInstance("SHA1PRNG");
			serverPRNG.setSeed(key);
		}
		catch (final NoSuchAlgorithmException e)
		{
			error("Unable to initialize PRNG's.", e);
			close();
			return;
		}
		/**
		 * If the key has been verified and the PRNG's have been successfully initialized, we'll
		 * send the client an encrypted 0x01 byte as a confirmation.
		 */
		send(crypt(new byte[] { 0x01 }, serverPRNG));
		final byte[] authType = crypt(receiveBytes(1), clientPRNG);
		if (authType == null)
		{
			close();
			return;
		}
		/**
		 * Clients making a new initial connection will send a 0x00 byte to make the server assign
		 * and send them a new Session ID.
		 */
		if (authType[0] == 0x00)
		{
			client = master.createClient();
			client.addSocket(this);
			send(crypt(client.getSessionID(), serverPRNG));
		}
		/**
		 * If the client already has one or more working connections and therefore already possesses
		 * a session ID, it will send it to the server and have it verified.
		 */
		else if (authType[0] == 0x01)
		{
			final byte[] sessionIDEncrypted = receiveBytes(Constants.session_id_length_bytes);
			if (sessionIDEncrypted == null)
			{
				close();
				return;
			}
			
			final byte[] sessionIDDecrypted = crypt(sessionIDEncrypted, clientPRNG);
			final HashMap<Integer, ServerClient> clientList = master.getClients();
			ServerClient clientCurrent;
			client = null;
			for (final Object key : clientList.entrySet())
			{
				clientCurrent = clientList.get(key);
				if (clientCurrent.getSessionID().equals(sessionIDDecrypted)
						&& clientCurrent.getIP().equals(ip))
				{
					/**
					 * If the client has reached or exceeded the maximum connection limit, the
					 * server will send an encrypted 0x02 byte and close the connection.
					 */
					if (client.activeConnections() >= Constants.connections_per_client)
					{
						send(crypt(new byte[] { 0x02 }, serverPRNG));
						close();
						return;
					}
					client = clientCurrent;
					break;
				}
			}
			/**
			 * If the server doesn't know the session ID the client sent to it, it will send back an
			 * encrypted 0x00 byte and close the connection.
			 */
			if (client == null)
			{
				send(crypt(new byte[] { 0x00 }, serverPRNG));
				close();
				return;
			}
			
		}
		/**
		 * If the client sends us unknown garbage, we'll close the connection.
		 */
		else
		{
			error("Invalid authType byte received.", null);
			close();
			return;
		}
		
	}
	
	/**
	 * Sends a raw <code>String</code> to the client by writing it into and flushing the output
	 * buffer.
	 * 
	 * @param string
	 *            The <code>String</code> which shall be sent.
	 * @return <code>true</code> if sending the <code>String</code> succeeded, otherwise
	 *         <code>false</code>.
	 */
	public synchronized boolean send(final String string)
	{
		try
		{
			for (int i = 0; i < string.length(); i++)
			{
				outputStream.write(string.charAt(i));
			}
			outputStream.flush();
		}
		catch (final Exception e)
		{
			cellngine.log(e, Constants.log_type.error);
			close();
			return false;
		}
		return true;
	}
	
	/**
	 * Sends n array of raw bytes to the client by writing them into and flushing the output buffer.
	 * 
	 * @param bytes
	 *            The bytes which shall be sent.
	 * @return <code>true</code> if sending the bytes succeeded, otherwise <code>false</code>.
	 */
	public boolean send(final byte[] bytes)
	{
		return send(new String(bytes));
	}
	
	/**
	 * Instantly disconnects this socket.
	 */
	public void close()
	{
		try
		{
			socket.close();
		}
		catch (final IOException e)
		{
			cellngine.log(e, Constants.log_type.error);
		}
		master.removeConnection(this);
	}
	
	/**
	 * @return Whether the connection is alive and can send and receive data.
	 */
	public boolean connectionAlive()
	{
		return socket.isConnected() && !socket.isInputShutdown() && !socket.isOutputShutdown()
				&& !socket.isClosed();
	}
	
	private void error(final String message, final Exception e)
	{
		cellngine.log("Network failure: " + message, Constants.log_type.error);
		cellngine.log("Connection state: " + (connectionAlive() ? "working" : "broken"),
				Constants.log_type.error);
		cellngine.log("Client IP: " + ip, Constants.log_type.error);
		if (e != null)
		{
			cellngine.log("Stacktrace:" + ip, Constants.log_type.error);
			cellngine.log(e, Constants.log_type.error);
		}
	}
	
	private byte[] receiveBytes(final int num)
	{
		int bytesRecieved = 0;
		int inputChar;
		final byte[] bytes = new byte[num];
		while ((inputChar = readChar()) != -1 && connectionAlive())
		{
			bytes[bytesRecieved] = (byte) ((char) inputChar);
			bytesRecieved++;
			if (bytesRecieved >= num)
			{
				return bytes;
			}
		}
		error("Unable to recieve specified amount of " + num + " bytes.", null);
		close();
		return null;
	}
	
	private int readChar()
	{
		try
		{
			return inputStream.read();
		}
		catch (final Exception e)
		{
			cellngine.log(e, Constants.log_type.error);
			close();
			return -1;
		}
	}
	
	private byte[] crypt(final byte[] input, final SecureRandom prng)
	{
		if (prng == null)
		{
			throw new NullPointerException();
		}
		final byte[] output = new byte[input.length];
		final byte[] prngKey = new byte[input.length];
		prng.nextBytes(prngKey);
		for (int i = 0; i < input.length; i++)
		{
			output[i] = (byte) (input[i] ^ prngKey[i]);
		}
		return output;
	}
}